Skip to content

feat(stack): expose encryptQuery on the WASM entry — searchable encryption on the edge#676

Merged
coderdan merged 12 commits into
mainfrom
feat/662-wasm-encrypt-query
Jul 16, 2026
Merged

feat(stack): expose encryptQuery on the WASM entry — searchable encryption on the edge#676
coderdan merged 12 commits into
mainfrom
feat/662-wasm-encrypt-query

Conversation

@coderdan

@coderdan coderdan commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Closes #662.

Why

@cipherstash/stack/wasm-inline's WasmEncryptionClient exposed only encrypt/decrypt/isEncrypted — encrypted WHERE-clause search was architecturally impossible on Deno/edge runtimes, even though the underlying protect-ffi WASM build has carried encryptQuery/encryptQueryBulk all along. The 2026-07-16 skilltester edge eval hit exactly this wall (its assessor confirmed grep -c encryptQuery dist/wasm-inline.js = 0, and the surface scored PARTIAL solely on this product gap).

What

  • encryptQuery(plaintext, { table, column, queryType? }) — mints a ciphertext-free EQL v3 query term (equality / free-text match / ORE range / JSON containment+selector). Cast to the column's eql_v3.query_<domain> in SQL to reach the indexed operators (worked example in the module docblock).
  • encryptQueryBulk(terms) — one round trip for many terms; position-stable, null values pass through as null.
  • Index-type resolution is a local ~40-line port of the native client's resolveIndexType — this module deliberately never imports @cipherstash/protect (it would drag the Node-only native FFI into the WASM bundle). Explicit queryType is validated against the column's configured indexes; omission infers by the same unique > match > ore > ste_vec priority as the native client; searchableJson infers selector-vs-term from the plaintext shape. A code comment binds the two implementations to behavioural lockstep.
  • Error surface: throws, consistent with the WASM entry's existing encrypt/decrypt (the Result envelope is a native-entry concept).

Tests

  • New unit suite (mocked WASM module): resolution from column indexes, explicit-queryType mapping + not-configured rejection, searchableJson selector/term inference, null → null without an FFI call, bulk position-stability + all-null short-circuit. 823/823 stack tests green, test:types clean.
  • Deno e2e smoke extended: mints a live term against real ZeroKMS and asserts it is v3 and ciphertext-free, plus the bulk path — runs in the existing "Run WASM E2E Tests (Deno)" CI job.

Not in scope

#663 (no credential-free dev auth on WASM) is the adjacent gap and remains open.

https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

Summary by CodeRabbit

  • New Features
    • Added searchable-encryption query support to the WASM inline client (Deno/edge), including encryptQuery and encryptQueryBulk.
    • Supports v3 query types (equality, free-text search, order/range, searchable JSON) with query-type inference when omitted.
    • Bulk encryption preserves input order and keeps null entries as null results.
  • Tests / CI
    • Added Deno e2e coverage for the full v3 query-type matrix with ciphertext-free wire-shape assertions.
    • Expanded WASM integration test runs and updated env-var behavior to fail fast when credentials are missing.

…ption on the edge

Closes #662. `WasmEncryptionClient` exposed only encrypt/decrypt/isEncrypted,
so encrypted WHERE-clause search was architecturally impossible on Deno/edge
runtimes — even though the protect-ffi WASM build has carried
encryptQuery/encryptQueryBulk all along. The 2026-07-16 skilltester edge run
hit exactly this wall (grep -c encryptQuery dist/wasm-inline.js == 0).

Adds encryptQuery + encryptQueryBulk minting ciphertext-free EQL v3 query
terms (equality / match / ORE / JSON containment+selector). Index-type
resolution is a local ~40-line port of the native client's resolveIndexType
(this module deliberately never imports @cipherstash/protect — that would
drag the Node-only native FFI into the WASM bundle); explicit queryType is
validated against the column's indexes, omission infers by the same
unique > match > ore > ste_vec priority as the native client, and
searchableJson infers selector-vs-term from the plaintext shape. Errors
throw (consistent with this surface); the bulk form is position-stable with
nulls passing through.

Tests: unit suite pins resolution, FFI opts shape, validation, null
handling, and bulk stability against a mocked WASM module; the Deno e2e
smoke now also mints a live term (v:3, ciphertext-free) and exercises the
bulk path.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan
coderdan requested a review from a team as a code owner July 16, 2026 08:36
@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 804fe3b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
stash Minor
@cipherstash/prisma-next Minor
@cipherstash/stack-drizzle Minor
@cipherstash/stack-supabase Minor
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ed583f01-8283-4748-a600-d346d8e84e7f

📥 Commits

Reviewing files that changed from the base of the PR and between a2cb138 and 804fe3b.

📒 Files selected for processing (1)
  • e2e/wasm/query-types.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • e2e/wasm/query-types.test.ts

📝 Walkthrough

Walkthrough

The WASM inline client adds EQL v3 encryptQuery and encryptQueryBulk, including index and JSON operator resolution, validation, null-preserving bulk behavior, SQL integration coverage, end-to-end tests, CI wiring, and release documentation.

Changes

WASM query encryption

Layer / File(s) Summary
Query API and FFI integration
packages/stack/src/wasm-inline.ts
Adds query-term types and single/bulk encryption methods with index and operator resolution, validation, and position-stable null handling.
Query behavior validation
packages/stack/__tests__/wasm-inline-query.test.ts, packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts
Tests inferred and explicit index types, JSON operators, validation failures, unsupported configurations, null handling, and bulk FFI behavior.
WASM SQL integration
packages/stack/integration/wasm/*, packages/stack/integration/vitest.config.ts, packages/test-kit/src/adapter.ts
Adds the WASM integration adapter, maps query operations to SQL, configures WASM aliases, and runs the shared family matrix against PostgreSQL.
End-to-end and CI coverage
e2e/wasm/*, .github/workflows/integration-drizzle.yml, .github/workflows/tests.yml
Validates live query-term wire shapes, fails fast on missing credentials, and includes WASM integration tests in CI.
Release documentation
.changeset/wasm-encrypt-query.md
Documents the new WASM query APIs and bulk null-preservation behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Application
  participant WasmEncryptionClient
  participant ProtectFfiWasmInline
  participant Postgres
  Application->>WasmEncryptionClient: encryptQuery(plaintext, options)
  WasmEncryptionClient->>WasmEncryptionClient: resolve indexType and queryOp
  WasmEncryptionClient->>ProtectFfiWasmInline: encrypt query term
  ProtectFfiWasmInline-->>WasmEncryptionClient: ciphertext-free EQL v3 term
  WasmEncryptionClient-->>Application: query term
  Application->>Postgres: execute SQL with query term
  Postgres-->>Application: matching row keys
Loading

Possibly related issues

  • cipherstash/stack issue 662 — Tracks the WASM encryptQuery and encryptQueryBulk functionality.
  • cipherstash/stack issue 664 — Covers EQL v3 query terms and their raw SQL usage.
  • cipherstash/stack issue 654 — Covers ciphertext-free searchable JSON query operands in another adapter.
  • cipherstash/protectjs-ffi issue 138 — Covers free-text query-term generation and validation in the WASM FFI path.

Possibly related PRs

Suggested reviewers: tobyhede

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main WASM searchable-encryption API addition, even though it omits the bulk-query support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/662-wasm-encrypt-query

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

coderdan added 2 commits July 16, 2026 18:41
The Deno e2e caught it live: serde on the WASM side fails with "invalid
type: unit value, expected a string" when queryOp is present-but-undefined
(the native NAPI layer tolerates undefined optionals; serde-wasm does not).
Omit the field at both call sites AND in resolveQueryIndex's return (the
queryTypeToQueryOp map only carries JSON ops, so eq/match/ore mapped to
undefined). Unit test now pins the field is ABSENT, not undefined.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Second live catch from the Deno e2e: encryptQueryBulk's opts field matches
the native ffiEncryptQueryBulk call ({ queries }), not the SDK-level term
naming. Unit-test mock aligned to the real FFI shape.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/stack/src/wasm-inline.ts (1)

401-470: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider documenting the as never casts per Biome-ignore convention.

The new FFI calls (Lines 414-422, 459-464) extend the file's existing as never pattern for opaque WASM handles. As per coding guidelines, **/*.{ts,tsx}: "Avoid as any, as never, and as unknown in source; narrow types or use a specific assertion, and document deliberate suppressions with the required Biome ignore reason." Since the FFI boundary genuinely needs an escape hatch here, a narrower typed wrapper (or at least a documented Biome-ignore comment explaining why) would satisfy the guideline without weakening the existing pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/src/wasm-inline.ts` around lines 401 - 470, Document the
deliberate `as never` casts at the `wasmEncryptQuery` and `wasmEncryptQueryBulk`
FFI boundaries using the repository’s required Biome-ignore reason convention,
or replace them with a narrower typed wrapper if an existing one is available.
Keep the casts limited to the opaque WASM handle and FFI argument boundaries.

Source: Coding guidelines

packages/stack/__tests__/wasm-inline-query.test.ts (1)

153-153: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Redundant null as unknown as string casts — WasmPlaintext already includes null.

All three sites cast a literal null through as unknown as string when assigning WasmQueryTerm.value. WasmQueryTerm.value is typed WasmPlaintext, and WasmPlaintext (packages/stack/src/wasm-inline.ts lines 151-158) already includes null in its union, so the double-cast is unnecessary and, per coding guidelines (**/*.{ts,tsx}: avoid as unknown), should simply be dropped.

  • packages/stack/__tests__/wasm-inline-query.test.ts#L153: replace value: null as unknown as string, with value: null,.
  • packages/stack/__tests__/wasm-inline-query.test.ts#L179: replace value: null as unknown as string, with value: null,.
  • e2e/wasm/roundtrip.test.ts#L127: replace value: null as unknown as string, with value: null,.
♻️ Proposed fix (apply to all three sites)
-        { value: null as unknown as string, table: users, column: users.email },
+        { value: null, table: users, column: users.email },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/__tests__/wasm-inline-query.test.ts` at line 153, Remove the
redundant null casts from all three WasmQueryTerm.value assignments:
packages/stack/__tests__/wasm-inline-query.test.ts lines 153-153 and 179-179,
and e2e/wasm/roundtrip.test.ts line 127. Set each value directly to null,
relying on the WasmPlaintext type and avoiding as unknown.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/stack/src/wasm-inline.ts`:
- Around line 270-317: Update resolveQueryIndex to support the OPE index: when
resolving orderAndRange, accept the configured ope index via the same ore-to-ope
fallback used by the stack-side resolver, and include ope in inference when ore
is absent. Preserve existing ore behavior and add coverage for a column
configured only with ope.

---

Nitpick comments:
In `@packages/stack/__tests__/wasm-inline-query.test.ts`:
- Line 153: Remove the redundant null casts from all three WasmQueryTerm.value
assignments: packages/stack/__tests__/wasm-inline-query.test.ts lines 153-153
and 179-179, and e2e/wasm/roundtrip.test.ts line 127. Set each value directly to
null, relying on the WasmPlaintext type and avoiding as unknown.

In `@packages/stack/src/wasm-inline.ts`:
- Around line 401-470: Document the deliberate `as never` casts at the
`wasmEncryptQuery` and `wasmEncryptQueryBulk` FFI boundaries using the
repository’s required Biome-ignore reason convention, or replace them with a
narrower typed wrapper if an existing one is available. Keep the casts limited
to the opaque WASM handle and FFI argument boundaries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b65d459c-b93a-4252-bf5b-b8f4859302b9

📥 Commits

Reviewing files that changed from the base of the PR and between bab1307 and 57bee46.

📒 Files selected for processing (4)
  • .changeset/wasm-encrypt-query.md
  • e2e/wasm/roundtrip.test.ts
  • packages/stack/__tests__/wasm-inline-query.test.ts
  • packages/stack/src/wasm-inline.ts

Comment thread packages/stack/src/wasm-inline.ts
…surface

Review feedback on #676:

- encryptQuery/encryptQueryBulk get complete TSDoc: @example per query type
  (equality, free-text, ORE range, JSON containment + selector) each with
  the ::eql_v3.query_<domain> SQL cast pattern, @param/@returns/@throws,
  the ciphertext-free semantics, and the domain→query-type mapping
  (including the query_jsonb irregular). WasmEncryptQueryOptions.queryType
  documents all four types and the inference rules (be explicit on
  multi-index domains like TextSearch).

- New e2e/wasm/query-types.test.ts: live matrix mirroring the adapters'
  per-query-type coverage — one real ZeroKMS term per type (equality/unique,
  freeTextSearch/match, orderAndRange/ore on IntegerOrd, searchableJson
  selector + containment on Json), plus inference and a mixed-type bulk
  with null passthrough. Every term asserted v3 + ciphertext-free. Each
  type crosses the WASM serde boundary live — the layer that already ate
  two mock-invisible bugs (undefined fields; the `queries` batch field).

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

Both review points addressed in the latest commit:

TSDocencryptQuery/encryptQueryBulk now carry complete docs: an @example per query type (equality, free-text, ORE range, JSON containment + JSONPath selector), each showing the ::eql_v3.query_<domain> SQL cast against the indexed operators; @param/@returns/@throws; the ciphertext-free term semantics; and the domain→query-type mapping including the query_jsonb irregular. WasmEncryptQueryOptions.queryType documents all four types plus the inference rules (and when to be explicit — multi-index domains like TextSearch).

Per-query-type e2e — new e2e/wasm/query-types.test.ts runs a live matrix mirroring the adapters' coverage: one real ZeroKMS term per type (equality→unique, freeTextSearch→match, orderAndRange→ore on IntegerOrd, searchableJson selector and containment on Json), plus index inference and a mixed-type bulk call with null passthrough — every term asserted v3 + ciphertext-free. Each type now crosses the WASM serde boundary live, which is the layer that already ate two mock-invisible bugs on this PR (explicitly-undefined fields rejected; the batch field being queries). Runs in the existing Deno CI job; skips identically to roundtrip.test.ts without creds.

coderdan added 2 commits July 16, 2026 19:11
Third live catch from the Deno query-type matrix: orderAndRange on a v3
IntegerOrd column failed 'index type "ore" is not configured' — the local
resolver was ported from packages/protect's OLDER helper, but stack has its
own @/encryption/helpers/infer-index-type with three v3 behaviours the port
lacked: the ore→ope swap for v3 ord domains, equality answered via the
ordering index on order-capable columns without `unique`, and ope in the
inference priority.

Delete the port entirely and import the shared resolver — it's type-only on
protect-ffi, so it's WASM-bundle-safe, and the lockstep-by-comment risk goes
away by construction. Unit tests pin the two previously-missing behaviours
(ope swap; equality-via-ordering) so they're covered without live creds.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
…velope

Fourth live catch from the Deno matrix — this one was in the TEST and the
docs, not the code: by protect-ffi's v3 contract there is no
encrypted-selector envelope ("JSONB selector (path) queries: the bare
Selector hash (a string) — bind it as the text argument of -> / ->>").
The e2e asserted v:3 on it; now asserts the bare non-empty string, and the
encryptQuery TSDoc example shows the correct -> binding (plus the @>
containment cast).

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Comment thread e2e/wasm/query-types.test.ts Outdated
Fifth live catch, again in the test's assumptions: eql_v3.query_jsonb
containment needles are strict {sv: [query-entry]} — no v field (per
protect-ffi's SteVecQuery contract; "no c" enforced by
is_valid_ste_vec_query_payload). Only SCALAR query terms carry the v:3
envelope. The e2e now pins each kind's true wire shape: scalar envelope,
bare selector string, sv containment needle — all ciphertext-free.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Comment thread e2e/wasm/query-types.test.ts Outdated
Comment thread packages/stack/src/wasm-inline.ts
…g creds

Review feedback on #676, part two:

1. The Deno e2e suites now FAIL with an actionable message naming every
   missing CS_* variable instead of silently skipping — a skipped credential
   suite reads as green coverage that never ran (the integration harness
   doctrine, applied to e2e/wasm).

2. New packages/stack/integration/wasm/ runs the FULL shared v3 family suite
   (@cipherstash/test-kit) over the WASM entry — every covered domain, every
   capability-derived positive and negative op, real ZeroKMS encryption and
   real row matching in a real Postgres, exactly like the Drizzle/Supabase
   adapters. The WASM surface mints terms only, so the adapter IS the SQL
   layer: per-field client.encrypt inserts, and each query op renders the
   documented raw-SQL recipe (eql_v3.<fn>(col, $n::jsonb::eql_v3.query_<d>),
   parenthesised gte/lte ranges, contains for bloom, ord_term ordering);
   in/notIn decompose over ONE encryptQueryBulk batch per list, exercising
   the bulk path on every family. Passing proves the whole edge recipe:
   WASM term → query-domain cast → indexed operator → correct rows.

   Wiring: test-kit's adapter name union gains 'wasm'; stack's integration
   vitest restores the REAL protect-ffi/auth wasm modules (the shared alias
   stubs them for unit tests); the suite joins integration-drizzle.yml's
   CS_IT_SUITE on both db legs, with wasm-inline.ts added to the path
   filters. Date/timestamp values cross the boundary as ISO strings
   (WasmPlaintext has no Date arm — serde doesn't consult toJSON).

   Validated locally to the credential boundary: with a live postgres-eql
   and dummy CS_* values, collection, EQL install, DDL, schema construction,
   and the WASM factory all execute, failing precisely at
   AccessKeyStrategy.create(INVALID_CRN) — CI's real secrets take it from
   there.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

Second round of review feedback addressed:

Fail loud, never skip — both Deno e2e suites now throw inside the test with a message naming every missing CS_* variable (previously ignore:-skipped). Verified locally: without creds they FAIL by name with the actionable message.

test-kit adapter-grade e2e — new packages/stack/integration/wasm/ runs the full shared v3 family suite over the WASM entry: every covered domain, every capability-derived positive and negative op, real ZeroKMS encryption, real row-matching against a real Postgres — the identical harness the Drizzle/Supabase adapters run. Since the WASM surface mints terms only, the adapter is itself the SQL layer, which makes the suite doubly useful: it executes the exact raw-SQL recipe the docs teach edge users (eql_v3.<fn>(col, $n::jsonb::eql_v3.query_<domain>), parenthesised ranges, contains for bloom, ord_term ordering), and in/notIn decompose over one encryptQueryBulk batch so the bulk path runs on every family.

Wiring: test-kit's adapter-name union gains 'wasm'; stack's integration vitest restores the real protect-ffi/auth WASM modules (the shared alias stubs them for unit tests); the suite joins integration-drizzle.yml's CS_IT_SUITE on both db legs, with wasm-inline.ts added to the path filters. One boundary note baked into the adapter: date/timestamp values cross as ISO strings (WasmPlaintext has no Date arm — serde doesn't consult toJSON).

Validated locally to the credential boundary (live postgres-eql + dummy CS_*): collection, CLI-driven EQL v3 install, DDL, schema construction, and the WASM factory all execute, failing precisely at AccessKeyStrategy.create(INVALID_CRN). The integration workflow's real secrets take it from there — first full run happens on this PR's CI.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
e2e/wasm/query-types.test.ts (1)

152-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow the selector without as unknown.

Use a type-predicate assertion or explicit typeof selector !== 'string' guard before reading .length.

As per coding guidelines, “Avoid as any, as never, and as unknown in source.” <coding_guidelines>

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/wasm/query-types.test.ts` around lines 152 - 155, Update the selector
validation assertion to avoid casting selector through unknown before accessing
length. Narrow selector with an explicit typeof selector check or a
type-predicate assertion, then validate that the resulting string is non-empty
while preserving the existing failure message.

Source: Coding guidelines

packages/stack/integration/wasm/adapter.ts (1)

284-291: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the blanket schema type escapes.

as never and as unknown suppress validation of the dynamic schema-to-column mapping. Use a typed schema construction helper or a specific documented assertion instead.

As per coding guidelines, “Avoid as any, as never, and as unknown in source.” <coding_guidelines>

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/integration/wasm/adapter.ts` around lines 284 - 291, Replace
the blanket as never and as unknown assertions in the tableSchema construction
and dynamic column lookup within the adapter’s schema-mapping flow. Introduce or
reuse a typed schema construction/access helper, or use a narrowly scoped
documented assertion that preserves validation of the schema-to-column mapping;
do not use any, never, or unknown casts.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@e2e/wasm/query-types.test.ts`:
- Around line 83-92: Update assertContainmentNeedle to explicitly assert that
the payload does not contain the version-envelope field v, alongside the
existing sv and c assertions, so only strict {sv: [...]} containment needles
pass.

In `@packages/stack/integration/wasm/adapter.ts`:
- Around line 335-343: Update expectRejected to swallow only the specific plain
Error representing the expected WASM rejection, and rethrow all other errors
from run(op), including database, network, or ZeroKMS failures. Preserve the
existing success path that throws when the operation is not rejected.

---

Nitpick comments:
In `@e2e/wasm/query-types.test.ts`:
- Around line 152-155: Update the selector validation assertion to avoid casting
selector through unknown before accessing length. Narrow selector with an
explicit typeof selector check or a type-predicate assertion, then validate that
the resulting string is non-empty while preserving the existing failure message.

In `@packages/stack/integration/wasm/adapter.ts`:
- Around line 284-291: Replace the blanket as never and as unknown assertions in
the tableSchema construction and dynamic column lookup within the adapter’s
schema-mapping flow. Introduce or reuse a typed schema construction/access
helper, or use a narrowly scoped documented assertion that preserves validation
of the schema-to-column mapping; do not use any, never, or unknown casts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a977be21-eec6-4ed1-9b25-9145bedb6320

📥 Commits

Reviewing files that changed from the base of the PR and between 57bee46 and 12165ac.

⛔ Files ignored due to path filters (1)
  • e2e/wasm/deno.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • .github/workflows/integration-drizzle.yml
  • e2e/wasm/query-types.test.ts
  • e2e/wasm/roundtrip.test.ts
  • packages/stack/__tests__/wasm-inline-query.test.ts
  • packages/stack/integration/vitest.config.ts
  • packages/stack/integration/wasm/adapter.ts
  • packages/stack/integration/wasm/families.integration.test.ts
  • packages/stack/src/wasm-inline.ts
  • packages/test-kit/src/adapter.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/stack/tests/wasm-inline-query.test.ts
  • packages/stack/src/wasm-inline.ts

Comment thread e2e/wasm/query-types.test.ts
Comment thread packages/stack/integration/wasm/adapter.ts
coderdan added 2 commits July 16, 2026 20:18
Two failures from the first live run of the WASM family suite:

1. Every family failed its domain CHECK at insert. Root cause is a
   postgres.js serializer trap, reproduced locally with a hand-valid
   envelope and no credentials: the $n::jsonb casts make the server
   type those params as jsonb, and postgres.js serializes jsonb params
   with JSON.stringify — so the adapter's pre-stringified payloads were
   encoded twice, arriving as jsonb *string* scalars that fail every
   jsonb_typeof(VALUE) = 'object' domain check. Bind the raw objects
   instead (storage payloads and query terms both) and let the driver
   stringify exactly once. A pre-insert assertWireEnvelope pin now
   names the payload, not the column domain, if a boundary shape ever
   regresses.

2. roundtrip.test.ts referenced env without calling requireEnv() —
   ReferenceError under Deno. Bound it, and added storage-payload wire
   pins (property access + JSON round-trip) so the Deno job also proves
   the envelope survives JSON.stringify — the exact crossing every SQL
   insert performs, which isEncrypted/decrypt alone cannot pin.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
The kit's ordering oracle breaks ties on row_key ascending and expects
the query to mirror it with a secondary ORDER BY — date/timestamp have
fewer samples than rows, so tied values are guaranteed and the previous
single-key ORDER BY returned them in arbitrary order (4 live failures:
{date,timestamp}_ord asc+desc).

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

CI is fully green, including the first complete live runs of the WASM family suite on both database legs.

The suite earned its keep immediately — two catches from live CI:

  1. postgres.js double-encoding (dac01cc): the $n::jsonb casts make the server type those params as jsonb, and postgres.js serializes jsonb params with JSON.stringify — so pre-stringified payloads were encoded twice, arriving as jsonb string scalars that failed every jsonb_typeof(VALUE) = 'object' domain CHECK. Reproduced locally with a hand-valid envelope and no credentials; the adapter now binds raw objects and lets the driver stringify once, with a pre-insert assertWireEnvelope pin so any future boundary-shape regression names the payload instead of a column domain. The Deno round-trip test also gained storage-payload wire pins (property access + JSON round-trip), which isEncrypted/decrypt alone cannot provide.
  2. Ordering tie-break (1a26d8c): the kit's oracle breaks ties on row_key and expects the query to mirror it; date/timestamp guarantee tied values, so the single-key ORDER BY eql_v3.ord_term(col) returned them in arbitrary order. Added the secondary row_key ASC.

encryptQuery/encryptQueryBulk themselves — the #662 surface — passed every family's positive and negative ops on the first live run after the binding fix.

https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

coderdan added 2 commits July 16, 2026 21:06
…rdening

Product fix: the WASM encryptQuery/encryptQueryBulk now run the same
pre-FFI guards as every other query path (assertValidNumericValue,
assertValueIndexCompatibility) — NaN/Infinity/out-of-int64 bigint and
numeric-on-match-index fail with the named errors the Node entry raises
instead of an opaque serde failure or a silently no-match term. Both
paths now share one toFfiQueryTerm() so the serde-omission and ope-swap
subtleties can't drift between single and bulk; the deliberate as-never
casts at the FFI seam carry biome-ignore reasons per AGENTS.md.

Test hardening from the same review:
- FFI stub gains encryptQuery/encryptQueryBulk so unmocked unit tests
  fail with the stub's named error, not 'is not a function'
- query-types.test.ts gains roundtrip's ffi:false permissions pin
- wasm adapter expectRejected discriminates capability rejections from
  infrastructure failures (mirrors the Supabase adapter's doctrine)
- empty in/notIn lists throw (parity with drizzle inArrayOp) instead of
  rendering 'WHERE ()'
- independent ZeroKMS round-trips run concurrently (per-field encrypts,
  bulk-insert rows, between lo/hi terms)
- dropped the redundant '| null' from encryptQuery's signature and the
  gratuitous 'null as unknown as string' casts it appeared to justify
- fixed the nonexistent .env.example pointer in both Deno suites and
  the stale skip-behavior comments in tests.yml/integration-drizzle.yml
- three new unit pins for the validation behavior (828 total green)

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
The suite header documents the wire contract as a strict {sv: [...]}
with no version field, but assertContainmentNeedle only rejected
ciphertext — a regression reintroducing the v envelope would have
passed. One-line pin from CodeRabbit's review.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

Comprehensive human + AI review (Coderabbit and Claude/Fable).

@coderdan
coderdan merged commit 5fcf982 into main Jul 16, 2026
13 checks passed
@coderdan
coderdan deleted the feat/662-wasm-encrypt-query branch July 16, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wasm-inline omits encryptQuery: no searchable encryption on Deno/edge runtimes

1 participant